文字渲染

关于字体及文字编码的一些概念

1. 字符

字符(Character):在计算机和电信技术中,一个字符是一个单位的字形、类字形单位或符号的基本信息。说的简单点字符是各种文字和符号的总称。一个字符可以是一个中文汉字、一个英文字母、一个阿拉伯数字、一个标点符号、一个图形符号或者控制符号等。

2. 字符集

字符集(Character Set):是指多个字符的集合。不同的字符集包含的字符个数不一样、包含的字符不一样、对字符的编码方式也不一样。例如GB2312是中国国家标准的简体中文字符集,GB2312收录简化汉字(6763个)及一般符号、序号、数字、拉丁字母、日文假名、希腊字母、俄文字母、汉语拼音符号、汉语注音字母,共 7445 个图形字符。而ASCII字符集只包含了128字符,这个字符集收录的主要字符是英文字母、阿拉伯字母和一些简单的控制字符。

另外,还有其他常用的字符集有 GBK字符集、GB18030字符集、Big5字符集、Unicode字符集等。

简单来说就是字符集定义了真实字符的编号。比如Unicode下,“朵格”就映射到了\u6735和\u683c编号。这个编号至少wiki称作码位。

3. 字符编码

字符编码(Character Encoding):字符编码是指一种映射规则,根据这个映射规则可以将某个字符映射成其他形式的数据以便在计算机中存储和传输。例如ASCII字符编码规定使用单字节中低位的7个比特去编码所有的字符,在这个编码规则下字母A的编号是65(ASCII码),用单字节表示就是0x41,因此写入存储设备的时候就是二进制的 01000001。每种字符集都有自己的字符编码规则,常用的字符集编码规则还有 UTF-8编码、GBK编码、Big5编码等。

编码是对字符集中的编号进行encode。

4. 码点

码点(Code Point):有些地方翻译为码值或内码。是指在某个字符集中,根据某种编码规则将字符编码后得到的值。比如在ASCII字符集中,字母A经过ASCII编码得到的值是65,那么65就是字符A在ASCII字符集中的码点。

总结:通俗解释字符集就是把字符放到一起的一个集合。而这个集合的每一个字符都对应一个数字,叫做码点。那么,这样就建立起来数字和字符之间的索引关系。那么,某个字符在计算机中怎么表示,具体占用几个字节等等,这些就需要编码规则来解决了。这个就是字符编码,他来解决根据某个规则来将字符映射到相应的码点上面。

utf-8

utf-8是Unicode字符集上的,变长的encode方案,以一字节为单位进行变长。

一字节编码 0♥♥♥♥♥♥♥ 显然完全兼容ascii。

二字节编码 110♥♥♥♥♥ 0♥♥♥♥♥♥♥

三字节编码 1110♥♥♥♥ 10♥♥♥♥♥♥ 10♥♥♥♥♥♥

四字节编码 11110♥♥♥ 10♥♥♥♥♥♥ 10♥♥♥♥♥♥ 10♥♥♥♥♥♥

utf-16

utf-16是Unicode字符集上的,变长的encode方案,以两字节为单位进行变长。

二字节编码 ♥♥♥♥♥♥♥♥ ♥♥♥♥♥♥♥♥

三字节编码 110110♥♥ ♥♥♥♥♥♥♥♥ 110111♥♥ ♥♥♥♥♥♥♥♥

Unicode

ascii中的0x00到0x0F都是C0控制符,不会参与直接显示,由于Unicode完全兼容ascii,因此Unicode也是如此。如果想要表示比较完整的拉丁字符,其码位为base Latin的0x0020到0x007F和Latin-1 Supplement的0x0080到0x00FF,合并起来就是0x0020到0x00FF。

OpenType

OpenType是目前最常用的,它包含了二次曲线描述的TrueType类型的轮廓,也包含了三次曲线描述的PostScript或说Type1的轮廓。

OpenType里包含了几万个Tables,每个Table包含了一系列信息,访问msdn查看详细信息hhea - Horizontal header table (OpenType 1.9) - Typography | Microsoft Learn

以hhea(Horizontal Header Table),包含了一系列和横向排列相关的信息。这些table都是连续的内存。

ImGui中渲染字体

1. Initialize font loading structure, check font data validity

ImGui使用stb来获取字体像素,主要因为stb极其简单,效率颇高。

首先要读取字体文件数据,而后使用stb调用stbtt_InitFont,此阶段会对字体文件进行分析(读取header)获取一些元信息。

此外还获取了最高的码点。

2. For every requested codepoint, check for their presence in the font data, and handle redundancy or overlaps between source fonts to avoid unused glyphs.

对于每个码点段内的码点,先测试此码点是否已经存在,若不存在则调用stbtt_FindGlyphIndex看看字体里包不包含此码点,包含的话就将此码点的值设置为真。

3. Unpack our bit map into a flat list (we now have all the Unicode points that we know are requested and available and not overlapping another)

4. Gather glyphs sizes so we can pack them in our virtual canvas.

获取字体的ascender和descender(负数,代表其在字体的baseline之下的距离),其差为字体的最大垂直高度(但是在hhea里的ascender和descender都是mac的,不知道为啥这里用这个)(以及单位为font metric),然后除以字体的垂直大小(就是类似16px这种)。

然后调用stbtt_FindGlyphIndex,输入Unicode Code Point获取对应字符在字体中的index,采取的是二分搜索。

而后调用stbtt_GetGlyphBitmapBoxSubpixel,获取指定位置字体像素值,有点复杂,就懒得看了。

5. Start packing

调用stbtt_PackBegin,需要包含stb_rect_pack.h头文件。

6. Pack each source font. No rendering yet, we are working with rectangles in an infinitely tall texture at this point.

pack使用的是skyline算法,其目的是将多个矩形装到二维箱子里。也就是说,这里在将每个字体填充到一个缓冲区内。由于字体最终会占据多少大小是未知的,因此先将箱子的高度设置为无限高,宽度设置为512,而后再进行裁剪。

但其实这些字体的大小都是一样的应该,pack只是获取了纹理的高度而已。

7. Allocate texture

在CPU上为纹理分配内存。

8. Render/rasterize font characters into the texture

调用stbtt_PackFontRangesRenderIntoRects。stb在将字体光栅化到纹理上的时候,将字体原有区域的左部和上部进行了一些padding,纹理真正的区域应当是文字区域的右下角。

而真正进行采样的函数是stbtt_MakeGlyphBitmapSubpixel,其中先调用stbtt_GetGlyphShape获取曲线的顶点信息。

下面是精彩的部分:

首先获取到字体轮廓的每一条winding(有方向的线,应该翻译成卷绕线) 对每一条winding:

​ 对于每条winding上的每个点(以储存的顺序进行)遍历:

​ 将每个点与上一个点进行比较(第一个点时则上一个点为最后一个点),若其y坐标相等则跳过,若上一个点高 于当前点则将此边标记为invert。之后再按y坐标由小到大的顺序将两个点的位置记录到对应边中。注意,在 这里的时候将Glyph的unit转换缩放为了pixel单位,可见。

以上伪过程就获取了每一条非水平边的边集。然后以边内最高点的y坐标排序,y坐标相等再以x坐标排序。

然后进入真正的rasterize过程:

采用的是扫描线的算法。对于从底到顶的每根扫描线:

​ 将每条起始点高于扫描线的边加入到Active边集。 ​ 后面的有点看不懂orz

9. Setup ImFont and glyphs for runtime

获取到每个字形在texture中的区域。


从这开始就已经获取了文字纹理了,只有1维,所以会先转换为3维的颜色。

创建Image

从像素转换为Image。

注意了,这一系列过程都是包围在vkBeginCommandBuffer和vkEndCommandBuffer、vkQueueSubmit、vkDeviceWaitIdle之间的。

初始化Image

非常值得注意的是,VK_FORMAT_R8G8B8_UNORM在某些设备上是不支持的(GTX 1660Ti如是说),需要用vkGetPhysicalDeviceFormatProperties检测。

VkImageCreateInfo info = {};
info.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
info.imageType = VK_IMAGE_TYPE_2D;
info.format = VK_FORMAT_R8G8B8A8_UNORM;
info.extent.width = width;
info.extent.height = height;
info.extent.depth = 1;
info.mipLevels = 1;
info.arrayLayers = 1;
info.samples = VK_SAMPLE_COUNT_1_BIT;
// 说想要直接访问图像Texel就需要设置为OPTIMAL
info.tiling = VK_IMAGE_TILING_OPTIMAL;
info.usage = VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT;
info.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
info.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
err = vkCreateImage(v->Device, &info, v->Allocator, &bd->FontImage);
check_vk_result(err);
VkMemoryRequirements req;
vkGetImageMemoryRequirements(v->Device, bd->FontImage, &req);
VkMemoryAllocateInfo alloc_info = {};
alloc_info.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
alloc_info.allocationSize = req.size;
alloc_info.memoryTypeIndex = ImGui_ImplVulkan_MemoryType(VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, req.memoryTypeBits);
err = vkAllocateMemory(v->Device, &alloc_info, v->Allocator, &bd->FontMemory);
check_vk_result(err);
// 在分配内存之后还要和Image进行绑定
err = vkBindImageMemory(v->Device, bd->FontImage, bd->FontMemory, 0);
check_vk_result(err);

创建ImageVIew

VkImageViewCreateInfo info = {};
info.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
info.image = bd->FontImage;
info.viewType = VK_IMAGE_VIEW_TYPE_2D;
info.format = VK_FORMAT_R8G8B8A8_UNORM;
info.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
info.subresourceRange.levelCount = 1;
info.subresourceRange.layerCount = 1;
err = vkCreateImageView(v->Device, &info, v->Allocator, &bd->FontView);
check_vk_result(err);

创建Sampler

// Bilinear sampling is required by default. Set 'io.Fonts->Flags |= ImFontAtlasFlags_NoBakedLines' or 'style.AntiAliasedLinesUseTex = false' to allow point/nearest sampling.
VkSamplerCreateInfo info = {};
info.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO;
info.magFilter = VK_FILTER_LINEAR;
info.minFilter = VK_FILTER_LINEAR;
info.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR;
info.addressModeU = VK_SAMPLER_ADDRESS_MODE_REPEAT;
info.addressModeV = VK_SAMPLER_ADDRESS_MODE_REPEAT;
info.addressModeW = VK_SAMPLER_ADDRESS_MODE_REPEAT;
info.minLod = -1000;
info.maxLod = 1000;
info.maxAnisotropy = 1.0f;
VkResult err = vkCreateSampler(device, &info, allocator, &bd->FontSampler);

创建DescriptorSetLayout

VkSampler sampler[1] = { bd->FontSampler };
VkDescriptorSetLayoutBinding binding[1] = {};
binding[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
binding[0].descriptorCount = 1;
binding[0].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
binding[0].pImmutableSamplers = sampler;
VkDescriptorSetLayoutCreateInfo info = {};
info.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
info.bindingCount = 1;
info.pBindings = binding;
VkResult err = vkCreateDescriptorSetLayout(device, &info, allocator, &bd->DescriptorSetLayout);

创建和更新DescriptorSet

bd->FontDescriptorSet = (VkDescriptorSet)ImGui_ImplVulkan_AddTexture(bd->FontSampler, bd->FontView, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
VkDescriptorSet ImGui_ImplVulkan_AddTexture(VkSampler sampler, VkImageView image_view, VkImageLayout image_layout)
{
    ImGui_ImplVulkan_Data* bd = ImGui_ImplVulkan_GetBackendData();
    ImGui_ImplVulkan_InitInfo* v = &bd->VulkanInitInfo;

    // Create Descriptor Set:
    // 先给Descriptor Set申请内存大概
    VkDescriptorSet descriptor_set;
    {
        VkDescriptorSetAllocateInfo alloc_info = {};
        alloc_info.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
        alloc_info.descriptorPool = v->DescriptorPool;
        alloc_info.descriptorSetCount = 1;
        alloc_info.pSetLayouts = &bd->DescriptorSetLayout;
        VkResult err = vkAllocateDescriptorSets(v->Device, &alloc_info, &descriptor_set);
        check_vk_result(err);
    }

    // Update the Descriptor Set:
    // 然后更新Image的Descriptor Set
    // 要创建多少纹理就需要创建多少个Descriptor Set
    {
        VkDescriptorImageInfo desc_image[1] = {};
        desc_image[0].sampler = sampler;
        desc_image[0].imageView = image_view;
        desc_image[0].imageLayout = image_layout;
        VkWriteDescriptorSet write_desc[1] = {};
        write_desc[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
        write_desc[0].dstSet = descriptor_set;
        write_desc[0].descriptorCount = 1;
        write_desc[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
        write_desc[0].pImageInfo = desc_image;
        vkUpdateDescriptorSets(v->Device, 1, write_desc, 0, nullptr);
    }
    return descriptor_set;
}

创建Buffer

Buffers in Vulkan are regions of memory used for storing arbitrary data that can be read by the graphics card.

其实由于GPU内存对齐等原因,因此常常使用vkGetBufferMemoryRequirements来获取实际内存大小。

然后又是经典的vk流程,先创建缓冲区,然后分配内存,再将内存和缓冲区绑定起来。

VkBufferCreateInfo buffer_info = {};
buffer_info.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
// buffer info里的size属性用的是直接用图像大小深度计算的大小,看起来这里的size并不需要考虑内存对齐问题
buffer_info.size = upload_size;
buffer_info.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
buffer_info.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
err = vkCreateBuffer(v->Device, &buffer_info, v->Allocator, &bd->UploadBuffer);
check_vk_result(err);
VkMemoryRequirements req;
vkGetBufferMemoryRequirements(v->Device, bd->UploadBuffer, &req);
bd->BufferMemoryAlignment = (bd->BufferMemoryAlignment > req.alignment) ? bd->BufferMemoryAlignment : req.alignment;
VkMemoryAllocateInfo alloc_info = {};
alloc_info.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
alloc_info.allocationSize = req.size;
alloc_info.memoryTypeIndex = ImGui_ImplVulkan_MemoryType(VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, req.memoryTypeBits);
// 实际分配内存
err = vkAllocateMemory(v->Device, &alloc_info, v->Allocator, &bd->UploadBufferMemory);
check_vk_result(err);
// 将buffer和内存绑定
err = vkBindBufferMemory(v->Device, bd->UploadBuffer, bd->UploadBufferMemory, 0);
check_vk_result(err);

将像素数据拷贝给Buffer

用vkMapMemory来获取到Buffer的指针,然后直接拷贝。显而易见的,这个内存区域是连续紧密的。此时的内存是在主机host的内存,不是GPU设备中的内存。需要调用vkFlushMappedMemoryRanges来将内存刷到GPU上。

以及,看spec上的话,map的时候是需要在host上分配连续内存的,而unmap也就顺理成章的会释放内存。

char* map = nullptr;
err = vkMapMemory(v->Device, bd->UploadBufferMemory, 0, upload_size, 0, (void**)(&map));
check_vk_result(err);
memcpy(map, pixels, upload_size);
VkMappedMemoryRange range[1] = {};
range[0].sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE;
range[0].memory = bd->UploadBufferMemory;
range[0].size = upload_size;
err = vkFlushMappedMemoryRanges(v->Device, 1, range);
check_vk_result(err);
// 之后不需要对这个内存进行修改,因此unmap
vkUnmapMemory(v->Device, bd->UploadBufferMemory);

拷贝Buffer给Image

MemoryBarrier是更加严格的ExecutionBarrier,它在进行执行顺序的控制之余还保证了缓存的刷新。例如AB是两个命令,|是MemoryBarrier,那么A | B的命令能保证在A执行完并将所有的cache都刷入之后(保证缓存的一致性),再执行B。

在Pipeline Barrier API中,可以指定三个数组。这三个数组,分别定义了不同类型的memory barrier:

  • 全局memory barrier
  • buffer上的memory barrier
  • image上的memory barrier

全局memory barrier只有src的访问mask和dst的访问mask,因此作用于当前所有的resource。需要具体操纵某个resource的时候,根据resource的类型,分别使用buffer或者image的memory barrier.[引用]

// 奇怪的是 居然没有设置srcAccessMask
VkImageMemoryBarrier copy_barrier[1] = {};
copy_barrier[0].sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
copy_barrier[0].dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
copy_barrier[0].oldLayout = VK_IMAGE_LAYOUT_UNDEFINED;
copy_barrier[0].newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
// 当队列族不变时都设置为VK_QUEUE_FAMILY_IGNORED
copy_barrier[0].srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
copy_barrier[0].dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
copy_barrier[0].image = bd->FontImage;
copy_barrier[0].subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
copy_barrier[0].subresourceRange.levelCount = 1;
copy_barrier[0].subresourceRange.layerCount = 1;
// 需要注意,置barrier是通过cmd进行的,说明这也是在command buffer里的一个命令
// 这里的srcStage是Host,dstStage是Transition,是从host拷贝到Image的过程大概
vkCmdPipelineBarrier(command_buffer, VK_PIPELINE_STAGE_HOST_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, copy_barrier);

// 看起来在GPU里 Buffer里的数据需要拷贝到Image里面
VkBufferImageCopy region = {};
region.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
region.imageSubresource.layerCount = 1;
region.imageExtent.width = width;
region.imageExtent.height = height;
region.imageExtent.depth = 1;
vkCmdCopyBufferToImage(command_buffer, bd->UploadBuffer, bd->FontImage, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &region);

// 在拷贝之后设置barrier,在执行完拷贝指令并将缓存刷到最新之后,再进行下一个Stage
VkImageMemoryBarrier use_barrier[1] = {};
use_barrier[0].sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
use_barrier[0].srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
use_barrier[0].dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
use_barrier[0].oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
use_barrier[0].newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
use_barrier[0].srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
use_barrier[0].dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
use_barrier[0].image = bd->FontImage;
use_barrier[0].subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
use_barrier[0].subresourceRange.levelCount = 1;
use_barrier[0].subresourceRange.layerCount = 1;
vkCmdPipelineBarrier(command_buffer, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, use_barrier);

后日谈之真正的文字渲染

上文创建了文字的纹理,下文就要探讨如何渲染文字。

对于每一个字符,根据ascii获得unicode码点再获得其在字体中的index,用index能很容易索引出字形的数据。

文字是在窗口上的特定位置的,拥有窗口坐标系的位置,每个文字会在文字位置创建其字形范围大小的四边形(我超好怪),并对四个顶点的UV设置为该字形在Atlas中对应的UV值。

这些新添加的Vertices会添加到ImGUI的draw list里。